page.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useState } from "react";
  3. import { useParams, useRouter } from "next/navigation";
  4. import { MessageList } from "@/components/dashboard/MessageList";
  5. import { MessageInput } from "@/components/dashboard/MessageInput";
  6. import { ChatHeader } from "@/components/dashboard/ChatHeader";
  7. import { Button } from "@/components/ui/button";
  8. import { useAuth } from "@/features/agent/hooks/useAuth";
  9. import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
  10. import {
  11. fetchMessages,
  12. sendMessage as sendMessageApi,
  13. markMessagesRead,
  14. } from "@/features/agent/services/messageApi";
  15. import { fetchConversationDetail } from "@/features/agent/services/conversationApi";
  16. import {
  17. ConversationDetail,
  18. ConversationSummary,
  19. MessageItem,
  20. MessagesReadPayload,
  21. ChatWebSocketPayload,
  22. } from "@/features/agent/types";
  23. import type { WSMessage } from "@/lib/websocket";
  24. import { toast } from "@/hooks/useToast";
  25. import { getAgentWSToken } from "@/utils/storage";
  26. export default function AgentChatPage() {
  27. const params = useParams();
  28. const router = useRouter();
  29. const { agent, loading: authLoading } = useAuth();
  30. const conversationId = useMemo(() => {
  31. const id = params?.conversationId;
  32. if (!id) {
  33. return null;
  34. }
  35. const parsed = Number.parseInt(String(id), 10);
  36. return Number.isNaN(parsed) ? null : parsed;
  37. }, [params]);
  38. const [messages, setMessages] = useState<MessageItem[]>([]);
  39. const [conversationDetail, setConversationDetail] =
  40. useState<ConversationDetail | null>(null);
  41. const [conversationSummary, setConversationSummary] =
  42. useState<ConversationSummary | null>(null);
  43. const [messageInput, setMessageInput] = useState("");
  44. const [loadingMessages, setLoadingMessages] = useState(true);
  45. const [sending, setSending] = useState(false);
  46. const [highlightKeyword, setHighlightKeyword] = useState("");
  47. const wsToken = getAgentWSToken() ?? undefined;
  48. const handleMarkMessagesRead = useCallback(
  49. async (
  50. conversationIdParam?: number,
  51. readerIsAgentParam?: boolean
  52. ) => {
  53. const targetConversationId = conversationIdParam ?? conversationId;
  54. const targetReaderIsAgent = readerIsAgentParam ?? true;
  55. if (!targetConversationId) {
  56. return;
  57. }
  58. const result = await markMessagesRead(
  59. targetConversationId,
  60. targetReaderIsAgent
  61. );
  62. if (!result || result.message_ids.length === 0) {
  63. return;
  64. }
  65. const idSet = new Set(result.message_ids);
  66. setMessages((prev) =>
  67. prev.map((msg) =>
  68. idSet.has(msg.id)
  69. ? {
  70. ...msg,
  71. is_read: true,
  72. read_at: result.read_at ?? msg.read_at ?? null,
  73. }
  74. : msg
  75. )
  76. );
  77. if (targetReaderIsAgent) {
  78. setConversationDetail((prev) =>
  79. prev ? { ...prev, unread_count: result.unread_count } : prev
  80. );
  81. setConversationSummary((prev) =>
  82. prev ? { ...prev, unread_count: result.unread_count } : prev
  83. );
  84. } else {
  85. setConversationDetail((prev) =>
  86. prev
  87. ? {
  88. ...prev,
  89. last_seen_at: result.read_at ?? prev.last_seen_at ?? null,
  90. }
  91. : prev
  92. );
  93. }
  94. },
  95. [conversationId]
  96. );
  97. const loadConversationDetail = useCallback(async () => {
  98. if (!conversationId) {
  99. return;
  100. }
  101. const detail = await fetchConversationDetail(conversationId);
  102. if (detail) {
  103. setConversationDetail(detail);
  104. setConversationSummary({
  105. id: detail.id,
  106. visitor_id: detail.visitor_id,
  107. agent_id: detail.agent_id,
  108. status: detail.status,
  109. created_at: detail.created_at,
  110. updated_at: detail.updated_at,
  111. last_message: detail.last_message,
  112. unread_count: detail.unread_count ?? 0,
  113. });
  114. }
  115. }, [conversationId]);
  116. const loadMessages = useCallback(async () => {
  117. if (!conversationId) {
  118. return;
  119. }
  120. setLoadingMessages(true);
  121. try {
  122. const data = await fetchMessages(conversationId);
  123. setMessages(data);
  124. // 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
  125. } catch (error) {
  126. console.error("拉取消息失败:", error);
  127. } finally {
  128. setLoadingMessages(false);
  129. }
  130. }, [conversationId]);
  131. useEffect(() => {
  132. if (!conversationId) {
  133. router.push("/agent/dashboard");
  134. }
  135. }, [conversationId, router]);
  136. useEffect(() => {
  137. if (!conversationId || !agent) {
  138. return;
  139. }
  140. loadConversationDetail();
  141. loadMessages();
  142. }, [conversationId, agent, loadConversationDetail, loadMessages]);
  143. const handleNewMessage = useCallback(
  144. (message: MessageItem) => {
  145. setMessages((prev) => {
  146. const exists = prev.some((item) => item.id === message.id);
  147. if (exists) {
  148. return prev;
  149. }
  150. return [...prev, message];
  151. });
  152. setConversationDetail((prev) => {
  153. if (!prev) {
  154. return prev;
  155. }
  156. const nextUnread =
  157. !message.sender_is_agent && message.message_type !== "system_message"
  158. ? (prev.unread_count ?? 0) + 1
  159. : prev.unread_count ?? 0;
  160. return {
  161. ...prev,
  162. updated_at: message.created_at,
  163. unread_count:
  164. message.sender_is_agent || message.message_type === "system_message"
  165. ? prev.unread_count ?? 0
  166. : nextUnread,
  167. last_message: {
  168. id: message.id,
  169. content: message.content,
  170. sender_is_agent: message.sender_is_agent,
  171. message_type: message.message_type ?? "user_message",
  172. is_read: Boolean(message.is_read),
  173. read_at: message.read_at ?? null,
  174. created_at: message.created_at,
  175. },
  176. };
  177. });
  178. setConversationSummary((prev) => {
  179. if (!prev) {
  180. return prev;
  181. }
  182. const nextUnread =
  183. !message.sender_is_agent && message.message_type !== "system_message"
  184. ? (prev.unread_count ?? 0) + 1
  185. : prev.unread_count ?? 0;
  186. return {
  187. ...prev,
  188. updated_at: message.created_at,
  189. unread_count:
  190. message.sender_is_agent || message.message_type === "system_message"
  191. ? prev.unread_count ?? 0
  192. : nextUnread,
  193. last_message: {
  194. id: message.id,
  195. content: message.content,
  196. sender_is_agent: message.sender_is_agent,
  197. message_type: message.message_type ?? "user_message",
  198. is_read: Boolean(message.is_read),
  199. read_at: message.read_at ?? null,
  200. created_at: message.created_at,
  201. },
  202. };
  203. });
  204. // 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
  205. if (message.conversation_id === conversationId) {
  206. loadConversationDetail();
  207. }
  208. },
  209. [conversationId, loadConversationDetail]
  210. );
  211. const handleSendMessage = useCallback(async () => {
  212. if (!conversationId || !agent?.id || !messageInput.trim() || sending) {
  213. return;
  214. }
  215. setSending(true);
  216. try {
  217. const created = await sendMessageApi({
  218. conversationId,
  219. content: messageInput,
  220. senderId: agent.id,
  221. });
  222. setMessageInput("");
  223. if (created) {
  224. handleNewMessage(created);
  225. } else {
  226. await loadMessages();
  227. }
  228. } catch (error) {
  229. console.error(error);
  230. toast.error((error as Error).message);
  231. } finally {
  232. setSending(false);
  233. }
  234. }, [agent?.id, conversationId, messageInput, sending, handleNewMessage, loadMessages]);
  235. const handleMessagesReadEvent = useCallback(
  236. (payload: MessagesReadPayload) => {
  237. if (!conversationId) {
  238. return;
  239. }
  240. // 检查对话ID是否匹配
  241. const payloadConversationId = payload?.conversation_id;
  242. if (payloadConversationId && payloadConversationId !== conversationId) {
  243. return;
  244. }
  245. const ids = Array.isArray(payload?.message_ids)
  246. ? payload.message_ids
  247. : [];
  248. if (ids.length === 0) {
  249. return;
  250. }
  251. const readAt = payload?.read_at;
  252. const readerIsAgent = Boolean(payload?.reader_is_agent);
  253. const unreadCount =
  254. typeof payload?.unread_count === "number"
  255. ? payload.unread_count
  256. : undefined;
  257. // 对于客服端:只有当 reader_is_agent === false 时(访客读取了客服的消息),
  258. // 才更新客服消息(sender_is_agent === true)的已读状态
  259. if (readerIsAgent) {
  260. // 客服读取了访客的消息,更新未读数(不更新消息的已读状态,因为这是客服读取的)
  261. if (unreadCount !== undefined) {
  262. setConversationDetail((prev) =>
  263. prev ? { ...prev, unread_count: unreadCount } : prev
  264. );
  265. setConversationSummary((prev) =>
  266. prev ? { ...prev, unread_count: unreadCount } : prev
  267. );
  268. }
  269. return;
  270. }
  271. // 访客读取了客服的消息,更新客服消息的已读状态
  272. const messageIdSet = new Set(ids);
  273. setMessages((prev) =>
  274. prev.map((msg) =>
  275. // 只更新客服自己的消息(sender_is_agent === true)的已读状态
  276. messageIdSet.has(msg.id) && msg.sender_is_agent
  277. ? {
  278. ...msg,
  279. is_read: true,
  280. read_at: readAt ?? msg.read_at ?? null,
  281. }
  282. : msg
  283. )
  284. );
  285. // 更新访客的最后活跃时间
  286. setConversationDetail((prev) =>
  287. prev
  288. ? { ...prev, last_seen_at: readAt ?? prev.last_seen_at ?? null }
  289. : prev
  290. );
  291. },
  292. [conversationId]
  293. );
  294. const handleWebSocketMessage = useCallback(
  295. (event: WSMessage<ChatWebSocketPayload>) => {
  296. if (!event) {
  297. return;
  298. }
  299. if (event.type === "new_message" && event.data) {
  300. const data = event.data as MessageItem;
  301. if (typeof data.conversation_id === "number") {
  302. handleNewMessage(data);
  303. }
  304. } else if (event.type === "messages_read") {
  305. // 确保处理已读事件,并传入对话ID
  306. const payload = event.data as MessagesReadPayload;
  307. if (!payload.conversation_id && event.conversation_id) {
  308. payload.conversation_id = event.conversation_id;
  309. }
  310. handleMessagesReadEvent(payload);
  311. }
  312. },
  313. [handleMessagesReadEvent, handleNewMessage]
  314. );
  315. useWebSocket<ChatWebSocketPayload>({
  316. conversationId,
  317. enabled: Boolean(conversationId),
  318. isVisitor: false,
  319. agentId: agent?.id ?? undefined,
  320. wsToken,
  321. onMessage: handleWebSocketMessage,
  322. onError: (error) => {
  323. // 静默处理错误,避免影响用户体验
  324. },
  325. onClose: () => {
  326. // 静默处理关闭,避免影响用户体验
  327. },
  328. });
  329. const handleBack = useCallback(() => {
  330. router.push("/agent/dashboard");
  331. }, [router]);
  332. const unreadCount =
  333. conversationDetail?.unread_count ?? conversationSummary?.unread_count ?? 0;
  334. if (authLoading || !agent) {
  335. return (
  336. <div className="flex justify-center items-center min-h-screen bg-gray-50 text-gray-600">
  337. 加载中...
  338. </div>
  339. );
  340. }
  341. if (!conversationId) {
  342. return null;
  343. }
  344. return (
  345. <div className="flex flex-col h-screen bg-gray-50">
  346. <div className="bg-white border-b border-gray-200">
  347. <div className="flex items-center gap-4 px-4 h-16">
  348. <Button
  349. onClick={handleBack}
  350. variant="outline"
  351. size="sm"
  352. >
  353. ← 返回
  354. </Button>
  355. <div className="flex-1">
  356. <ChatHeader
  357. conversationId={conversationId}
  358. lastSeenAt={conversationDetail?.last_seen_at}
  359. unreadCount={unreadCount}
  360. onMarkAllRead={() => handleMarkMessagesRead(conversationId, true)}
  361. onRefresh={() => {
  362. loadMessages();
  363. loadConversationDetail();
  364. }}
  365. />
  366. </div>
  367. </div>
  368. </div>
  369. <div className="flex-1 flex flex-col bg-white">
  370. <MessageList
  371. messages={messages}
  372. loading={loadingMessages}
  373. highlightKeyword={highlightKeyword}
  374. onHighlightClear={() => setHighlightKeyword("")}
  375. currentUserIsAgent={true}
  376. conversationId={conversationId}
  377. onMarkMessagesRead={handleMarkMessagesRead}
  378. />
  379. <MessageInput
  380. value={messageInput}
  381. onChange={setMessageInput}
  382. onSubmit={handleSendMessage}
  383. sending={sending}
  384. />
  385. </div>
  386. </div>
  387. );
  388. }